[Миграция] Миграция проекта yt-dlp-web на другой сервер с виртуальным окружением и systemd службой

Введение

Данная инструкция поможет перенести готовый проект yt-dlp-web с одного сервера на другой с сохранением виртуального окружения и настройки systemd службы для автоматического запуска.


Что такое Deno и зачем его устанавливать

Deno – современная платформа для запуска JavaScript и TypeScript вне браузера, разработанная автором Node.js. Она обеспечивает безопасное и удобное выполнение скриптов, включая улучшенную поддержку современных возможностей языка и встроенную работу с файлами, сетью и окружением.

Крайние релизы yt-dlp теперь требуют стороннюю среду выполнения JavaScript для корректной работы с YouTube. Это связано с масштабными изменениями на стороне YouTube и обновлениями кода yt-dlp. Встроенный интерпретатор JavaScript внутри yt-dlp с сентября перестал справляться с новыми требованиями.

Подробнее можно узнать в обсуждении разработчиков: https://github.com/yt-dlp/yt-dlp/issues/14404

Разработчики yt-dlp рекомендуют использовать Deno.

Установка Deno

Для установки Deno на Linux выполните следующую команду в терминале:

curl -fsSL https://deno.land/install.sh | sh

cкрипт спросит: Edit shell configs to add deno to the PATH? (Y/n) ставим y далее будет: █Deno was added to the PATH. You may need to restart your shell for it to become available.

Чтобы изменения вступили в силу в текущей сессии терминала, просто закройте его и откройте заново или выполните:

source ~/.bashrc

ИЛИ

source ~/.profile

Проверьте корректность установки и версию Deno командой:

deno --version

Чтобы проверить, что yt-dlp действительно вызывает deno, можно запустить yt-dlp из консоли с нужной ссылкой в режиме отладки или с более подробным выводом, например:

/root/.local/bin/yt-dlp -v <url>

Там может появиться информация об использовании deno или о вызовах JavaScript-окружения.

Шаг 1. Архивирование проекта

Перейдите в корневой каталог с проектом:

cd /root  
tar czvf yt-dlp-web.tar.gz yt-dlp-web

Шаг 2. Копирование на новый сервер

Перенесите архив на новый сервер, например с помощью scp:

scp yt-dlp-web.tar.gz user@new-server:/root/

Шаг 3. Распаковка и подготовка окружения

На новом сервере распакуйте архив:

cd /root  
tar xzvf yt-dlp-web.tar.gz

Шаг 4. Установка Python и создание виртуального окружения

4.1 Установка Python и pip (если отсутствуют):

apt update && apt install -y python3 python3-venv python3-pip ffmpeg

Для Debian/Linux бинарный файл ffmpeg не имеет расширения, а исполняемый файл называется просто:

ffmpeg

Его можно положить, например, в  /usr/local/bin/  и дать права на выполнение:

chmod +x ffmpeg
sudo mv ffmpeg /usr/local/bin/

4.2 Создание виртуального окружения и установка зависимостей:

cd /root/yt-dlp-web  
python3 -m venv venv  
source venv/bin/activate  
pip install -r requirements.txt

Если файла requirements.txt нет, установите основные зависимости вручную:

pip install fastapi uvicorn yt-dlp

Завершите работу с виртуальным окружением:

deactivate

Шаг 5. Проверка путей и прав

python  
YT_DLP_PATH = "/root/.local/bin/yt-dlp"

Отредактируйте путь, если yt-dlp находится в другом месте.

mkdir -p /root/yt-dlp-web/downloads  
chmod 755 /root/yt-dlp-web/downloads

Шаг 6. Настройка systemd службы

Создайте файл службы /etc/systemd/system/myapp.service со следующим содержимым:

[Unit]  
Description=FastAPI сервер yt-dlp-web  
After=network.target  

[Service]  
User=root  
WorkingDirectory=/root/yt-dlp-web  
# Добавляем DENO_DIR и убеждаемся, что путь к бинарнику deno в PATH
Environment="PATH=/root/yt-dlp-web/venv/bin:/root/.deno/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
Environment="DENO_DIR=/root/.deno"
ExecStart=/root/yt-dlp-web/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000  
Restart=always  
RestartSec=5s  
StandardOutput=journal  
StandardError=journal  

[Install]  
WantedBy=multi-user.target

Вариант содержимого с TLS сертификатом:

[Unit]
Description=FastAPI сервер yt-dlp-web
After=network.target

[Service]
User=root
WorkingDirectory=/root/yt-dlp-web
Environment="PATH=/root/yt-dlp-web/venv/bin:/root/.deno/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
ExecStart=/root/yt-dlp-web/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --ssl-certfile /etc/letsencrypt/live/server.tonicman.ru/fullchain.pem --ssl-keyfile /etc/letsencrypt/live/server.tonicman.ru/privkey.pem
Restart=always
RestartSec=5s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Если настроена маскировка (Fallback) для 3X-UI + Nginx:

[Unit]
Description=FastAPI сервер yt-dlp-web
After=network.target

[Service]
User=root
WorkingDirectory=/root/yt-dlp-web
Environment="PATH=/root/yt-dlp-web/venv/bin:/root/.deno/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
# Убрали SSL и добавили --root-path /ytdl
ExecStart=/root/yt-dlp-web/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --root-path /ytdl
Restart=always
RestartSec=5s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

6.1 Перезагрузка systemd, включение и запуск сервиса

systemctl daemon-reload  
systemctl enable myapp.service  
systemctl start myapp.service  
systemctl status myapp.service

Шаг 7. Тестирование


Шаг 8. Рекомендации по безопасности и удобству

source venv/bin/activate  
pip freeze > requirements.txt  
deactivate

Итог


Обновление yt-dlp

Для корректной работы проекта важно иметь актуальную версию yt-dlp, расположенную по пути, который указан в main.py:

YT_DLP_PATH = "/root/.local/bin/yt-dlp"

Проверка текущей версии

Проверь установленную версию командой:

/root/.local/bin/yt-dlp --version

Обновление yt-dlp

/usr/bin/python3 -m pip install --upgrade yt-dlp --user

Флаг --user гарантирует, что обновление установится в локальную папку пользователя root, обновляя файл по нужному пути. После обновления снова проверь версию.

Права на выполнение

Убедись, что yt-dlp имеет права на исполнение:

chmod +x /root/.local/bin/yt-dlp

Важно

Если используешь другой путь к yt-dlp, измени переменную YT_DLP_PATH в main.py на актуальный и обновляй yt-dlp соответственно.

Обновление ffmpeg

Для корректной работы с видео и аудио проект использует ffmpeg. Рекомендуется иметь актуальную версию.

ffmpeg -version

Обновление

Обновить ffmpeg можно стандартными командами:

sudo apt update
sudo apt install ffmpeg

Если нужна самая свежая версия, можно добавить официальный PPA:

sudo add-apt-repository ppa:jonathonf/ffmpeg-4
sudo apt update
sudo apt install ffmpeg

Установка статической версии ffmpeg:

cd /usr/local/bin
sudo wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
sudo tar -xvf ffmpeg-release-amd64-static.tar.xz
cd ffmpeg-*-amd64-static
sudo cp ffmpeg ffprobe /usr/local/bin/
sudo chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
ffmpeg -version

Или скачать сборки с официального сайта https://ffmpeg.org/download.html.

Код проекта

main.py:

import os
import shlex
import tempfile
import asyncio
import re
import time
import unicodedata
import logging
import difflib
from urllib.parse import quote
from typing import Optional, List
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse, StreamingResponse, PlainTextResponse
from fastapi.templating import Jinja2Templates

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["POST", "GET", "OPTIONS"],
    allow_headers=["*"],
)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
DOWNLOADS_DIR = os.path.join(BASE_DIR, "downloads")
os.makedirs(DOWNLOADS_DIR, exist_ok=True)
templates = Jinja2Templates(directory=TEMPLATES_DIR)

DEFAULT_OPTIONS = [
    "-S",
    "res:1080,vcodec:av1 (bv*[height<=1080]+ba) / res:1080,vcodec:vp9 (bv*[height<=1080]+ba) / bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a] / best[ext=mp4] / best / bv*[height<=1080]+ba / b",
    "--merge-output-format",
    "mp4",
]

YT_DLP_PATH = "/root/.local/bin/yt-dlp"

OUTPUT_TEMPLATE = os.path.join(DOWNLOADS_DIR, "%(title)s.%(ext)s")

def clean_vk_url(url: str) -> str:
    """Очищает ссылки VK от лишних параметров плейлиста, сохраняя домен пользователя"""
    if "vkvideo.ru" in url or "vk.com/video" in url:
        match = re.search(r"video-?\d+_\d+", url)
        if match:
            video_id = match.group(0)
            domain = "vkvideo.ru" if "vkvideo.ru" in url else "vk.com"
            return "https://" + domain + "/" + video_id
    return url

def sanitize_filename(name: str) -> str:
    name = name.replace('\uff5c', '_')
    return re.sub(r'[<>:"/\\|?*]', '_', name)


def normalize_filename(name: str) -> str:
    name = unicodedata.normalize('NFC', name)
    name = name.replace('\uff5c', '|')
    name = name.replace('?', '|')
    return name


def filter_user_options(options: str) -> List[str]:
    if not options.strip():
        return []
    opts = shlex.split(options)
    filtered = []
    skip_next = False
    for opt in opts:
        if skip_next:
            skip_next = False
            continue
        if opt in ("-o", "--output"):
            skip_next = True
            continue
        filtered.append(opt)
    return filtered


def is_extract_audio(options: str) -> bool:
    opts = shlex.split(options)
    return any(opt in ("-x", "--extract-audio") for opt in opts)


async def run_yt_dlp_cmd_async(cmd: List[str]):
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    if proc.returncode != 0:
        raise Exception(f"yt-dlp error: {stderr.decode()}")

    class Result:
        def __init__(self, stdout: str):
            self.stdout = stdout

    return Result(stdout.decode())


async def get_target_filename(url: str, options: str) -> Optional[str]:
    url = clean_vk_url(url)
    user_opts = filter_user_options(options)
    try:
        if is_extract_audio(options):
            cmd = [YT_DLP_PATH] + user_opts + ["--get-filename", "-o", OUTPUT_TEMPLATE, url]
        else:
            cmd = [YT_DLP_PATH] + DEFAULT_OPTIONS + user_opts + ["--get-filename", "-o", OUTPUT_TEMPLATE, url]
        result = await run_yt_dlp_cmd_async(cmd)
        fname = result.stdout.strip().split("\n")[-1]
        fname = normalize_filename(fname)
        logger.debug(f"Определённое имя файла: {fname}")  
        filename = sanitize_filename(os.path.basename(fname))
        return filename
    except Exception:
        return None


def build_cmd(url: str, options: str, output_template: str) -> List[str]:
    url = clean_vk_url(url)
    user_opts_raw = shlex.split(options) if options.strip() else []

    if not any(opt == "--newline" for opt in user_opts_raw):
        user_opts_raw.append("--newline")

    if not any(opt.startswith("--hls-prefer-ffmpeg") for opt in user_opts_raw):
        user_opts_raw.append("--hls-prefer-ffmpeg")

    if not any(opt.startswith("--retries") for opt in user_opts_raw):
        user_opts_raw.extend(["--retries", "20"])

    if not any(opt.startswith("--fragment-retries") for opt in user_opts_raw):
        user_opts_raw.extend(["--fragment-retries", "50"])

    if not any(opt.startswith("--sleep-interval") for opt in user_opts_raw):
        user_opts_raw.extend(["--sleep-interval", "10"])

    if not any(opt.startswith("--max-sleep-interval") for opt in user_opts_raw):
        user_opts_raw.extend(["--max-sleep-interval", "30"])

    if not any(opt.startswith("--no-playlist") for opt in user_opts_raw):
        user_opts_raw.append("--no-playlist")

    if not any(opt.startswith("--user-agent") for opt in user_opts_raw):
        user_opts_raw.extend([
            "--user-agent",
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/115.0 Safari/537.36"
        ])

    if is_extract_audio(options):
        user_opts = []
        skip_next = False
        for opt in user_opts_raw:
            if skip_next:
                skip_next = False
                continue
            if opt in ("-f", "--format"):
                skip_next = True
                continue
            user_opts.append(opt)

        audio_opts = [
            "--extract-audio",
            "--audio-format", "m4a",
            "--audio-quality", "320",
            "--postprocessor-args", "-strict -2"
        ]
        for o in audio_opts:
            if not any(uopt == o or uopt.startswith(o + "=") for uopt in user_opts):
                user_opts.append(o)

        cmd = [YT_DLP_PATH, "-o", output_template] + user_opts + [url]
    else:
        user_opts = []
        skip_next = False
        for opt in user_opts_raw:
            if skip_next:
                skip_next = False
                continue
            if opt in ("-o", "--output"):
                skip_next = True
                continue
            user_opts.append(opt)

        cmd = [YT_DLP_PATH, "-o", output_template] + DEFAULT_OPTIONS + user_opts + [url]

    return cmd


async def stream_yt_dlp(cmd, expected_filename):
    import json

    def sanitize_and_lower(s: str) -> str:
        s = s.replace('\uff5c', '_')
        s = re.sub(r'[<>:"/\\|?*]', '_', s)
        s = s.lower()
        return s

    process = None
    try:
        process = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
        )

        last_percent = 0.0

        while True:
            try:
                line_bytes = await asyncio.wait_for(process.stdout.readline(), timeout=900)
            except asyncio.TimeoutError:
                yield "\n\u274c Ошибка: время ожидания загрузки истекло.\n"
                break

            if not line_bytes:
                break

            line_raw = line_bytes.decode("utf-8", errors="ignore").rstrip('\n')
            if "\r" in line_raw:
                parts = line_raw.split('\r')
                line = parts[-1].strip()
            else:
                line = line_raw.strip()

            if (
                line.startswith("[https @") or line.startswith("[http @") or line.startswith("[dashsegments]")
                or line.startswith("[hls @") or line.startswith("[h264 @") or line.startswith("[aac @")
                or line.startswith("[mp4 @") or line.startswith("[mov,mp4,m4a,3gp,3g2,mj2 @")
                or line.startswith("[mpegts @") or line.startswith("[segment @")
                or line.startswith("[data @") or line.startswith("[meta @")
            ):
                continue

            yield line + "\n"

            m = re.search(r"(\d{1,3}(?:\.\d+)?)%", line)
            if m:
                try:
                    percent = float(m.group(1))
                    if percent >= last_percent:
                        last_percent = percent
                        yield json.dumps({"progress": percent}) + "\n"
                except Exception:
                    pass

        await process.wait()

        for f in os.listdir(DOWNLOADS_DIR):
            if '\uff5c' in f:
                try:
                    os.rename(os.path.join(DOWNLOADS_DIR, f), os.path.join(DOWNLOADS_DIR, f.replace('\uff5c', '_')))
                except: pass

        base_expected_s = sanitize_and_lower(os.path.splitext(expected_filename)[0])
        best_match = None
        best_ratio = 0.0

        for f in os.listdir(DOWNLOADS_DIR):
            full_path = os.path.join(DOWNLOADS_DIR, f)
            if os.path.isfile(full_path):
                ratio = difflib.SequenceMatcher(None, base_expected_s, sanitize_and_lower(os.path.splitext(f)[0])).ratio()
                if ratio > best_ratio and ratio > 0.5:
                    best_ratio = ratio
                    best_match = full_path

        file_path = best_match

        if not file_path:
            yield json.dumps({"error": "Не удалось найти скачанный файл."}) + "\n"
            return

        base, ext = os.path.splitext(file_path)
        for ext_to_del in [".part", ".ytdl", ".ytdl-temp"]:
            tmp_path = base + ext_to_del
            if os.path.exists(tmp_path):
                try: os.unlink(tmp_path)
                except: pass

        sanitized_name = sanitize_filename(os.path.basename(file_path))
        sanitized_path = os.path.join(DOWNLOADS_DIR, sanitized_name)
        if file_path != sanitized_path:
            try:
                if os.path.exists(sanitized_path): os.remove(sanitized_path)
                os.rename(file_path, sanitized_path)
                file_path = sanitized_path
            except: pass

        download_url = "/download_file?file_name=" + quote(sanitized_name) + "&orig_name=" + quote(os.path.basename(file_path))
        yield json.dumps({
            "status": "ready",
            "download_url": download_url,
            "original_name": os.path.basename(file_path)
        }) + "\n"

    except Exception as ex:
        yield "\n\u274c Ошибка сервера: " + str(ex) + "\n"
    finally:
        if process and process.returncode is None:
            try: process.kill()
            except: pass
            await process.wait()


@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})


@app.post("/download_stream")
async def download_stream(
    url: str = Form(...),
    options: str = Form(""),
    cookies: Optional[UploadFile] = File(None)
):
    temp_cookie_file = None
    url = clean_vk_url(url)
    try:
        filename = await get_target_filename(url, options)
        if not filename:
            raise HTTPException(status_code=400, detail="Не удалось определить имя файла")

        cmd = build_cmd(url, options, OUTPUT_TEMPLATE)

        if cookies:
            temp = tempfile.NamedTemporaryFile(delete=False, mode='wb')
            temp.write(await cookies.read())
            temp.close()
            temp_cookie_file = temp.name
            cmd += ["--cookies", temp_cookie_file]

        return StreamingResponse(stream_yt_dlp(cmd, filename), media_type="text/plain")

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        if temp_cookie_file and os.path.exists(temp_cookie_file):
            try: os.unlink(temp_cookie_file)
            except: pass


@app.post("/clear_history_files")
async def clear_history_files():
    try:
        for filename in os.listdir(DOWNLOADS_DIR):
            file_path = os.path.join(DOWNLOADS_DIR, filename)
            if os.path.isfile(file_path):
                os.remove(file_path)
        return {"status": "ok"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/download_file")
async def download_file(file_name: str, orig_name: Optional[str] = Query(None)):
    safe_path = os.path.abspath(os.path.join(DOWNLOADS_DIR, file_name))
    if not safe_path.startswith(DOWNLOADS_DIR) or not os.path.isfile(safe_path):
        raise HTTPException(status_code=404)
    return FileResponse(path=safe_path, filename=orig_name or file_name)


@app.get("/formats", response_class=PlainTextResponse)
async def get_formats(url: str = Query(...)):
    url = clean_vk_url(url)
    cmd = [YT_DLP_PATH, "-F", url]
    process = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await process.communicate()
    return stdout.decode() if process.returncode == 0 else stderr.decode()


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

index.html:

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>yt-dlp Web Downloader</title>
<style>
  body {
    background-color: #2c2f33;
    color: #eee;
    font-family: Verdana, Geneva, Tahoma, sans-serif;
    margin: 0; padding: 0;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: flex-start;
    padding-top: 40px;
  }
  #main-container {
    background-color: #1e2124;
    border-radius: 10px;
    padding: 20px 30px;
    max-width: 640px;
    width: 100%;
    box-shadow: 0 0 15px rgba(0,0,0,0.7);
  }
  label {
    font-size: 14px;
    color: #8eb9ff;
    display: block;
    margin-bottom: 6px;
  }
  textarea, input[type="text"], input[type="file"] {
    width: 100%;
    padding: 8px 12px;
    margin-bottom: 15px;
    border-radius: 5px;
    border: none;
    font-size: 14px;
    background-color: #3a3d42;
    color: #eee;
    box-sizing: border-box;
    resize: vertical;
  }
  button {
    width: 100%;
    padding: 12px 0;
    border-radius: 6px;
    border: none;
    background-color: #2979ff;
    color: white;
    font-weight: 600;
    font-size: 16px;
    cursor: pointer;
    margin-top: 5px;
    transition: background-color 0.3s ease;
  }
  button:hover:not(:disabled) {
    background-color: #1669ff;
  }
  button:disabled {
    background-color: #555a66;
    cursor: not-allowed;
  }
  #button-group {
    display: flex;
    gap: 10px;
    margin-top: 5px;
  }
  #button-group button {
    width: unset;
    flex: 1;
  }
  #progress-container {
    background-color: #3a3d42;
    border-radius: 6px;
    height: 24px;
    width: 100%;
    position: relative;
    overflow: hidden;
    margin-top: 10px;
    box-sizing: border-box;
  }
  #progress-bar {
    background-color: #4da6ff;
    height: 100%;
    width: 0;
    transition: width 0.25s ease-out;
    box-shadow: 0 0 8px 2px #4da6ff;
    border-radius: 6px 0 0 6px;
  }
  #progress-text {
    position: absolute;
    width: 100%;
    top: 0;
    left: 0;
    text-align: center;
    color: #eee;
    font-weight: 700;
    line-height: 24px;
    user-select: none;
    pointer-events: none;
    font-size: 14px;
  }
  #terminal {
    background-color: #0b0d12;
    color: #0f0;
    font-family: monospace;
    font-size: 13px;
    white-space: pre-wrap;
    overflow-y: auto;
    height: 220px;
    padding: 12px;
    border-radius: 8px;
    margin-top: 20px;
    line-height: 1.3;
    user-select: text;
  }
  #history {
    margin-top: 30px;
    max-height: 140px;
    overflow-x: auto;
    overflow-y: hidden;
    background: #22272d;
    border-radius: 8px;
    padding: 10px 15px 10px 15px;
    white-space: nowrap;
    position: relative;
    scrollbar-width: thin;
    scrollbar-color: #4da6ff transparent;
  }
  #historyList {
    list-style: none;
    padding: 0;
    margin: 0;
    display: inline-flex;
    gap: 16px;
    align-items: center;
  }
  #historyList li {
    display: inline-flex;
    flex-direction: column;
    align-items: center;
    max-width: 200px;
    white-space: normal;
    word-break: break-word;
  }
  #historyList a {
    color: #4da6ff;
    text-decoration: none;
    font-size: 13px;
    text-align: center;
    display: block;
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  #historyList a:hover {
    text-decoration: underline;
  }
  .copyBtn {
    background: #555a66;
    color: #eee;
    border: none;
    border-radius: 3px;
    padding: 2px 6px;
    margin-top: 6px;
    cursor: pointer;
    font-size: 11px;
    width: 100%;
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: background-color 0.2s ease;
    user-select: none;
  }
  .copyBtn:hover {
    background-color: #2979ff;
  }
  #clearHistoryBtn {
    position: absolute;
    top: 8px;
    right: 15px;
    background: #d35400;
    width: auto;
    padding: 6px 14px;
    border-radius: 6px;
    border: none;
    color: white;
    font-weight: 600;
    font-size: 14px;
    cursor: pointer;
    transition: background-color 0.3s ease;
  }
  #clearHistoryBtn:hover {
    background-color: #b04100;
  }
  .options-help {
    color: #aaa;
    font-size: 13px;
    margin-top: -10px;
    margin-bottom: 15px;
    line-height: 1.4;
  }
  .options-help code {
    background-color: #2e3137;
    padding: 2px 4px;
    border-radius: 3px;
    font-size: 13px;
  }
  nav#useful-links ul {
    list-style: none;
    padding: 0;
    margin: 30px auto 0;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 15px;
    font-size: 14px;
  }
  nav#useful-links li a {
    color: #4da6ff;
    text-decoration: none;
    padding: 10px 14px;
    border-radius: 6px;
    transition: background-color 0.3s ease;
    display: inline-block;
  }
  nav#useful-links li a:hover,
  nav#useful-links li a:focus {
    background-color: #1669ff;
    color: white;
  }
  @media (max-width: 480px) {
    nav#useful-links ul {
      flex-direction: column;
      gap: 10px;
    }
    nav#useful-links li a {
      font-size: 16px;
      padding: 10px 14px;
    }
  }
  @media (max-width: 480px) {
  #clearHistoryBtn {
    padding: 6px 10px;
    font-size: 12px;
    border-radius: 4px;
    top: 12px;
    right: 12px;
  }
}
</style>
</head>
<body>
<div id="main-container" role="main" aria-label="Панель загрузки видео">
  <h2>Загрузить видео по ссылке с опциями</h2>

  <form id="downloadForm" onsubmit="event.preventDefault(); startDownload();">
    <label for="url">Вставьте ссылку сюда:</label>
    <textarea id="url" name="url" placeholder="https://www.youtube.com/watch?v=..." autocomplete="off" spellcheck="false" aria-required="true"></textarea>

    <label for="cookies">Файл cookies (если нужен):</label>
    <input type="file" id="cookies" name="cookies" aria-describedby="cookiesHelp" />

    <label for="options">Дополнительные опции yt-dlp (по желанию):</label>
    <input type="text" id="options" name="options" placeholder="--extract-audio --audio-format mp3 --audio-quality 320" autocomplete="off" spellcheck="false" />

    <div class="options-help" id="cookiesHelp">
      Примеры использования опций yt-dlp:<br/>
      <code>--format</code> &mdash; выбрать качество или формат (best, mp4, mp3 и др.)<br/>
      <code>--merge-output-format mp4</code> &mdash; объединить в MP4<br/>
      <code>--write-sub --sub-lang ru,en</code> &mdash; скачать субтитры на русском и английском<br/>
      <code>--no-playlist</code> &mdash; скачать только одно видео (без плейлистов)<br/>
      <code>--ignore-errors</code> &mdash; пропускать ошибки, продолжать загрузку<br/>
      <code>--extract-audio</code> &mdash; извлечь только аудио<br/>
      <code>--audio-format</code> &mdash; конвертировать аудио (mp3, wav, aac и др.)<br/>
      <code>--hls-use-mpegts</code> &mdash; улучшить скачивание прямых эфиров (HLS)<br/>
    </div>

    <div id="button-group" role="group" aria-label="Кнопки управления загрузкой">
      <button type="submit" id="startBtn">Начать загрузку</button>
      <button type="button" id="cancelBtn" disabled>Отмена</button>
      <button type="button" id="downloadBtn" disabled>Загрузить на ПК</button>
      <button type="button" id="showFormatsBtn">Показать форматы</button>
    </div>

    <div id="progress-container" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-live="polite" aria-label="Прогресс загрузки">
      <div id="progress-bar"></div>
      <div id="progress-text">0%</div>
    </div>
  </form>

  <textarea id="formatsOutput" rows="15" readonly spellcheck="false" style="background-color:#2e3137; color:#ccc; margin-top:15px;"></textarea>

  <div id="terminal" aria-live="polite" aria-atomic="true" role="log"></div>

  <div id="history" role="region" aria-label="История загрузок">
    <h3>История загрузок</h3>
    <button type="button" id="clearHistoryBtn">Очистить историю</button>
    <ul id="historyList"></ul>
  </div>

  <nav id="useful-links" aria-label="Полезные ссылки">
    <ul>
      <li><a href="https://github.com/yt-dlp/yt-dlp/releases" target="_blank" rel="noopener noreferrer">yt-dlp</a></li>
      <li><a href="https://github.com/denoland/deno/" target="_blank" rel="noopener noreferrer">Deno</a></li>
      <li><a href="https://www.johnvansickle.com/ffmpeg/" target="_blank" rel="noopener noreferrer">FFmpeg Static Builds</a></li>
      <li><a href="https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md" target="_blank" rel="noopener noreferrer">Список поддерживаемых сайтов</a></li>
      <li><a href="https://github.com/GyanD/codexffmpeg/releases" target="_blank" rel="noopener noreferrer">FFmpegGyanD</a></li>
      <li><a href="https://github.com/yt-dlp/FFmpeg-Builds/releases" target="_blank" rel="noopener noreferrer">FFmpeg-Builds</a></li>
</nav>
</div>

<script>
  const term = document.getElementById('terminal');
  const downloadBtn = document.getElementById("downloadBtn");
  const startBtn = document.getElementById("startBtn");
  const cancelBtn = document.getElementById("cancelBtn");
  const showFormatsBtn = document.getElementById("showFormatsBtn");
  const formatsOutput = document.getElementById("formatsOutput");
  const progressBar = document.getElementById("progress-bar");
  const progressText = document.getElementById("progress-text");
  const historyList = document.getElementById("historyList");
  const clearHistoryBtn = document.getElementById("clearHistoryBtn");

  let downloadUrl = "";
  let controller = null;
  let isDownloadCompleted = false; // ФЛАГ ДЛЯ ФОРСИРОВАНИЯ 100%

  const apiBase = "";

  function appendTerminal(line) {
    if (!line) return;
    line = line.trimEnd();
    if (line.includes('\r')) {
      const parts = line.split('\r');
      const lastPart = parts[parts.length - 1].trim();
      let lines = term.textContent.split('\n');
      if (lines.length > 0) lines.pop();
      lines.push(lastPart);
      term.textContent = lines.join('\n');
    } else {
      term.textContent += line + '\n';
    }
    const maxLines = 500;
    let allLines = term.textContent.split('\n');
    if (allLines.length > maxLines) {
      term.textContent = allLines.slice(allLines.length - maxLines).join('\n');
    }
    term.scrollTop = term.scrollHeight;
  }

  function showProgress(percent) {
    if (isDownloadCompleted && percent < 100) return; // БЛОКИРУЕМ ОТКАТ
    const p = Math.min(100, Math.max(0, percent));
    progressBar.style.width = p + '%';
    progressText.textContent = `${Math.round(p)}%`;
    progressBar.parentElement.setAttribute('aria-valuenow', p);
  }

  function resetProgress() {
    isDownloadCompleted = false;
    showProgress(0);
  }

  function addToHistory(name, url) {
    if (!name || !url) return;
    let history = JSON.parse(localStorage.getItem('downloadHistory') || '[]');
    if (history.find(item => item.url === url)) return;

    history.unshift({name: name, url: url});
    if (history.length > 50) history.pop();
    localStorage.setItem('downloadHistory', JSON.stringify(history));
    loadHistory();
  }

  function loadHistory() {
    let history = JSON.parse(localStorage.getItem('downloadHistory') || '[]');
    const seen = new Set();
    historyList.innerHTML = '';
    history.forEach(item => {
      if (seen.has(item.url)) return;
      seen.add(item.url);
      const li = document.createElement('li');
      const link = document.createElement('a');
      const finalUrl = new URL(item.url, window.location.origin + window.location.pathname).href;
      link.href = finalUrl;
      link.target = '_blank';
      link.rel = "noopener noreferrer";
      link.textContent = item.name.split(/[\\/]/).pop();
      const copyBtn = document.createElement('button');
      copyBtn.textContent = 'Копировать';
      copyBtn.className = 'copyBtn';
      copyBtn.title = 'Скопировать ссылку в буфер';
      copyBtn.onclick = () => {
        navigator.clipboard.writeText(finalUrl).then(() => {
          alert('Ссылка скопирована в буфер обмена');
        }, () => {
          alert('Ошибка копирования ссылки');
        });
      };
      li.appendChild(link);
      li.appendChild(copyBtn);
      historyList.appendChild(li);
    });
  }

  clearHistoryBtn.onclick = async () => {
    if (confirm("Вы действительно хотите очистить историю загрузок и удалить файлы с сервера?")) {
      try {
        const resp = await fetch(apiBase + "clear_history_files", {
          method: "POST"
        });
        if (!resp.ok) {
          const text = await resp.text();
          alert("Ошибка очистки файлов на сервере: " + text);
          return;
        }
        localStorage.removeItem('downloadHistory');
        loadHistory();
        alert("История и файлы на сервере очищены.");
      } catch (e) {
        alert("Ошибка при очистке: " + e.message);
      }
    }
  };

  downloadBtn.onclick = () => {
    if (downloadUrl) {
      window.open(downloadUrl, "_blank");
    } else {
      alert("Сначала начните загрузку и дождитесь её окончания.");
    }
  };

  cancelBtn.onclick = () => {
    if (controller) {
      controller.abort();
    }
  };

  showFormatsBtn.onclick = async () => {
    const urlInput = document.getElementById("url");
    const url = urlInput.value.trim();
    const formatsOutput = document.getElementById("formatsOutput");
    formatsOutput.value = "";
    if (!url) {
      alert("Пожалуйста, введите ссылку для отображения форматов");
      urlInput.focus();
      return;
    }

    formatsOutput.value = "Загрузка форматов...";
    try {
      const response = await fetch(apiBase + "formats?url=" + encodeURIComponent(url));
      if (!response.ok) {
        formatsOutput.value = "Ошибка при получении форматов: " + response.statusText;
        return;
      }
      const text = await response.text();
      formatsOutput.value = text || "Нет доступных форматов для данного URL.";
    } catch (e) {
      formatsOutput.value = "Ошибка при запросе форматов: " + e.message;
    }
  };

  async function startDownload() {
    isDownloadCompleted = false;
    const url = document.getElementById("url").value.trim();
    const options = document.getElementById("options").value.trim();
    const cookiesFile = document.getElementById("cookies").files[0];
    downloadUrl = "";
    startBtn.disabled = true;
    cancelBtn.disabled = false;
    downloadBtn.disabled = true;
    term.textContent = "";
    resetProgress();

    if (!url) {
      alert("Пожалуйста, введите ссылку на видео!");
      startBtn.disabled = false;
      cancelBtn.disabled = true;
      return;
    }

    const formData = new FormData();
    formData.append("url", url);
    formData.append("options", options);
    if (cookiesFile) formData.append("cookies", cookiesFile);

    controller = new AbortController();

    try {
      const response = await fetch(apiBase + "download_stream", {
        method: "POST",
        body: formData,
        signal: controller.signal,
      });

      if (!response.ok) {
        appendTerminal("\u274c Ошибка сервера при загрузке.");
        alert("Ошибка сервера при загрузке.");
        startBtn.disabled = false;
        cancelBtn.disabled = true;
        return;
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split("\n");
        buffer = lines.pop();

        for (const lineRaw of lines) {
          const line = lineRaw.trim();
          if (!line) continue;
          appendTerminal(line);

          try {
            const obj = JSON.parse(line);
            if (obj.progress !== undefined) showProgress(obj.progress);
            if (obj.status === "ready" && obj.download_url) {
              isDownloadCompleted = true; // СТАВИМ ФЛАГ ТУТ
              showProgress(100);
              downloadUrl = obj.download_url;
              downloadBtn.disabled = false;
              alert("Загрузка завершена!");
              addToHistory(obj.original_name || "видео", downloadUrl);
              return;
            }
          } catch (e) {
            // Если это не JSON, ищем процент в тексте
            const progressMatch = line.match(/(\d{1,3}(?:\.\d+)?)%/);
            if (progressMatch) showProgress(parseFloat(progressMatch[1]));
          }
        }
      }
    } catch (e) {
      if (e.name === "AbortError") appendTerminal("\u274c Отмена.");
      else alert("Ошибка запроса: " + e.message);
    } finally {
      startBtn.disabled = false;
      cancelBtn.disabled = true;
    }
  }
  loadHistory();
</script>
</body>
</html>